마지막 업데이트 2026-07-29
use-ai-audio-noise-guard.ts · 분석일: 2025-05-20 · 담당: charles-na
getStats()의 concealedSamples 지표로 PLC 활성 구간을 직접 측정해,
그 구간의 기계음 후보 프레임을 카운트에서 제외하는 게이팅(방안 B)이다.
구현 과정에서 catch 블록이 보호를 역방향으로 무력화하는 여죄 버그도 함께 잡았다.
피해 현장은 수업 중의 게스트 페이지. 핑퐁이가 말하던 도중 세션이 갑자기 끊겼다는 신고가 들어왔다. 끊은 것은 침입자가 아니라 우리 편 경비원 — 기계음 감지 로직이 스스로 세션을 중단시킨 것이다. 문제는 그 순간 기계음이 실제로는 없었다는 점이다.
AI 아바타(핑퐁이)가 발화하는 도중, 네트워크가 일시적으로 불안정해지는 순간에 기계음 감지 로직이 오탐(False Positive)을 발생시켜 세션이 중단되는 현상이 보고됨.
CV < 0.35 AND ZCR ≥ 0.04
CV(변동계수)가 낮고 ZCR(영점교차율)이 적당히 높으면 기계음으로 판정. 정상 음성의 CV ≈ 0.85, 기계음의 CV ≈ 0.27.
WebRTC 스택(Chrome)이 패킷 손실 구간을 감추기 위해 직전 오디오 프레임을 반복하는 기법. 수신자에게는 짧은 침묵 대신 균일한 파형이 들린다.
재현 조건이 "네트워크 순단"이라는 점이 첫 단서였다. 네트워크가 흔들릴 때 브라우저는 침묵 대신 직전 프레임을 반복 재생하는 PLC를 돌린다 — 감지 로직이 보는 파형이 그 순간 어떻게 변하는지, 범행을 프레임 단위로 재구성했다.
onNoiseDetected() 발화 → 세션 강제 종료.
| 신호 유형 | CV (변동계수) | ZCR (영점교차율) | RMS 패턴 | 기계음 판정 |
|---|---|---|---|---|
| 정상 음성 | ~0.85 (높음) | ~0.08–0.15 | 불규칙 변동 | 통과 안 함 |
| 기계음 TTS | ~0.27 (낮음) | ≥ 0.04 | 주기적·안정 | 감지됨 |
| PLC 반복 프레임 | ≈ 0 (매우 낮음) | 원래 음성과 동일 | 완전 균일(복사) | 오탐! (기존) |
| 무음 | ∞ (mean≈0) | ≈ 0 | RMS < -50dB | RMS 필터로 제외 |
범인은 기계음으로 완벽하게 위장했다 — CV·ZCR 두 검문소를 모두 정상 통과한다. 음성 신호만 보는 검문으로는 잡을 수 없다는 뜻이다. 검거 전략을 두 갈래로 검토했다.
네트워크 불안정 감지 시 기계음 판정 자체를 끄는 방법.
WebRTC getStats()로 PLC 활성 여부를 직접 측정하여, PLC 활성 구간에서 나온 기계음 후보 프레임만 제외하는 방법.
concealedSamples 등)를 직접 측정하므로 정확.
신호 처리 계층을 수정하지 않고 게이팅만 추가.
PLC 프레임 자체를 오디오 신호로 구분하는 것은 매우 어렵다.
직전 프레임 복사본이므로 합법적인 음성 반복과 구별되지 않는다.
반면 WebRTC inbound-rtp 통계는 브라우저가
내부적으로 PLC를 적용한 샘플 수(concealedSamples)를
직접 카운팅하므로 100% 신뢰도로 PLC 활성 여부를 알 수 있다.
위장(음성 신호)을 벗기려 들지 않고, 범인의 출입 기록(브라우저가 직접 카운팅하는 concealedSamples)을 조회하기로 했다 — 위장은 완벽해도 출입 기록은 못 지운다. 방안 B로 검거에 들어갔다.
// PLC 감지용 WebRTC stats 샘플링 주기 const PLC_STATS_INTERVAL_MS = 500; // PLC active 상태가 stats 없이도 유지되는 시간 const PLC_STATE_TTL_MS = 1_500; // 500ms 구간 내 concealed 비율이 이 값 이상이면 PLC 활성 const PLC_CONCEALED_SAMPLE_RATIO_THRESHOLD = 0.03; // concealmentEvents 증가량이 이 값 이상이면 PLC 활성 const PLC_CONCEALMENT_EVENTS_THRESHOLD = 2; // packetsLost 증가량이 이 값 이상이면 PLC 활성 const PLC_PACKETS_LOST_THRESHOLD = 3; // jitter가 80ms 이상이면 PLC 위험 구간으로 간주 const PLC_JITTER_THRESHOLD_MS = 80;
export interface AudioInboundStats { timestamp: number; concealedSamples?: number; concealmentEvents?: number; totalSamplesReceived?: number; silentConcealedSamples?: number; packetsLost?: number; packetsReceived?: number; jitter?: number; }
interface PlcState { active: boolean; updatedAt: number; concealedSampleRatio: number | null; concealmentEventsDelta: number; packetsLostDelta: number; jitterMs: number | null; }
500ms마다 getAudioInboundStats()를 호출해 inbound-rtp 통계를 읽는다.
이전 샘플 대비 델타값을 계산하여 PLC 활성 여부를 판정한다.
const concealedSamplesDelta = stats.concealedSamples - prev.concealedSamples; const totalSamplesReceivedDelta = stats.totalSamplesReceived - prev.totalSamplesReceived; const concealedSampleRatio = concealedSamplesDelta / totalSamplesReceivedDelta; const active = concealedSampleRatio >= 0.03 // 수신 샘플의 3% 이상이 PLC || concealmentEventsDelta >= 2 // 500ms 안에 PLC 이벤트 2회 이상 || packetsLostDelta >= 3 // 500ms 안에 패킷 3개 이상 손실 || jitterMs >= 80; // 지터 80ms 이상
PLC_STATE_TTL_MS = 1500ms — PLC가 감지된 후 stats가 정상화되더라도
1.5초간 active 상태를 유지한다. 링 버퍼(700ms) + 재검증(200ms) 전체 윈도우를 커버하기 위함.
isNoiseFrame은 CV/ZCR 조건만 본다.
isEffectiveNoiseFrame은 여기에 PLC 비활성 조건을 추가로 AND한다.
PLC가 활성화된 동안은 기계음 후보 프레임이 카운트에 포함되지 않는다.
if (isNoiseFrame && isPlcActive) { logger.info("Noise candidate frame suppressed during PLC-active window", { cv: cv.toFixed(4), rmsDb: rmsDb.toFixed(1), zcr: zcr.toFixed(4), concealedSampleRatio: ..., concealmentEventsDelta: plcState.concealmentEventsDelta, packetsLostDelta: plcState.packetsLostDelta, jitterMs: ..., }); }
기계음 후보이지만 PLC로 억제된 프레임은 INFO 레벨로 기록되어
실제 억제가 동작하는지 추적 가능.
게이팅을 붙이는 과정에서 예외 처리 코드 한 줄이 눈에 걸렸다. getStats 실패 시 active = false로 덮어쓰는 catch 블록 — 이대로면 보호가 가장 필요한 순간에 보호가 꺼진다.
pc.getStats()는 ICE 재연결(ICE restart) 중 예외를 던질 수 있다.
ICE 재연결은 정확히 네트워크가 불안정한 순간에 발생한다.
즉, PLC 보호가 가장 필요한 시점에 getStats가 실패하고,
catch가 active = false를 덮어써서 PLC 보호를 해제하는 역설이 발생한다.
PLC_STATE_TTL_MS(1500ms)가 만료되기 전까지는 active가 자연스럽게 유지되며,
이후 TTL 만료로 active = false가 된다.
검거가 끝났으니 새 경비망이 어디에 어떻게 깔렸는지, 그리고 타이밍상 빈틈이 없는지를 기록으로 남긴다.
pc.getStats()const getAiAudioInboundStats = useCallback(async () => { const pc = pcRef.current; if (!pc || pc.connectionState === "closed") return null; const statsReport = await pc.getStats(); let result: AudioInboundStats | null = null; statsReport.forEach((report) => { if (report.type === "inbound-rtp" && report.kind === "audio") { result = { timestamp: report.timestamp, concealedSamples: report.concealedSamples, concealmentEvents: report.concealmentEvents, totalSamplesReceived: report.totalSamplesReceived, silentConcealedSamples:report.silentConcealedSamples, packetsLost: report.packetsLost, packetsReceived: report.packetsReceived, jitter: report.jitter, }; } }); return result; }, []);
useAiAudioNoiseGuard({ aiAudioStream: noiseTestHarness.syntheticNoiseStream ?? aiSession.aiAudioStream, isPeerTalking: noiseTestHarness.isNoiseSimulating || aiSession.isPeerTalking, enabled: noiseGuardEnabled, getAudioInboundStats: noiseTestHarness.syntheticNoiseStream ? undefined // 테스트 모드: PLC 보호 OFF (합성 신호라 실제 stats 없음) : aiSession.getAiAudioInboundStats, // 프로덕션: 실제 WebRTC stats onNoiseDetected: () => { /* ... */ }, });
syntheticNoiseStream)은 1kHz 사인파를 직접 생성하므로
WebRTC stats가 존재하지 않는다. undefined를 전달하면
noise guard 내부에서 plcStateRef.active = false로 유지되어
기계음 감지가 의도대로 동작한다.
| 상수명 | 값 | 의미 | 비고 |
|---|---|---|---|
PLC_STATS_INTERVAL_MS |
500 | WebRTC stats 폴링 주기 | 너무 짧으면 성능 부담 |
PLC_STATE_TTL_MS |
1500 | PLC 활성 상태 유지 시간 | 링 버퍼(700) + 재검증(200) + 여유 |
PLC_CONCEALED_SAMPLE_RATIO_THRESHOLD |
0.03 (3%) | 500ms 내 concealed 샘플 비율 임계치 | 낮을수록 민감 |
PLC_CONCEALMENT_EVENTS_THRESHOLD |
2 | 500ms 내 concealment 이벤트 횟수 | 1로 낮추면 더 민감 |
PLC_PACKETS_LOST_THRESHOLD |
3 | 500ms 내 패킷 손실 수 | 2로 낮추면 더 보수적 |
PLC_JITTER_THRESHOLD_MS |
80ms | 지터 임계치 | 40ms로 낮추면 더 이른 감지 |
PLC_JITTER_THRESHOLD_MS = 80ms는 보수적이다. 체감 품질 저하는 보통 40ms부터 시작되므로 40ms로 낮추면 더 이른 시점에 PLC를 감지할 수 있다.
PLC_PACKETS_LOST_THRESHOLD = 3도 절대 카운트라 세션 길이에 영향 받을 수 있다. 2로 낮추는 것도 검토 가능.
localhost:3000/app/guest/ 게스트 페이지 접속 후 수업 시작window.__noiseTest?.start() 실행Mechanical noise confirmed after re-verificationNoise candidate frame suppressed during PLC-active window 로그 확인PLC active 로그 순서 확인:INFO Noise candidate frame suppressed during PLC-active window { cv: "0.0012", concealmentEventsDelta: 4, ... } ... (패킷 복구 후 1.5초 TTL 만료) ... INFO Noise re-verification failed, resuming normal analysis (← 재검증 중 PLC 억제로 기계음 카운트 부족 → 오탐 회피)
| 파일 | 변경 내용 |
|---|---|
apps/web/hooks/use-ai-audio-noise-guard.ts |
PLC 상수 및 인터페이스 추가 · samplePlcState() 콜백 구현 ·
isEffectiveNoiseFrame 게이팅 적용 (링 버퍼 + 재검증) ·
stats 폴링 useEffect 추가 · catch 블록 버그 수정
|
apps/web/entities/guest-session/model/use-ai-session.ts |
getAiAudioInboundStats 콜백 추가 —
RTCPeerConnection.getStats()에서 inbound-rtp audio 리포트 추출
|
apps/web/entities/guest-page-session/model/use-guest-page-session.ts |
useAiAudioNoiseGuard에 getAudioInboundStats prop 연결 ·
테스트 모드에서는 undefined 전달 (PLC 보호 OFF)
|
Generated: 2025-05-20 · PPI dev analysis